fix(snapshot): per-slice merge + WireSnapshotState type at the wire boundary - #77
Merged
Merged
Conversation
…oundary A v2.9.3 server's snapshot has a `settings` object without `customThemes` (commit 99262b2 added the field on 2026-05-22, the day after v2.9.3 shipped). The old top-level shallow merge only filled in entirely- missing slices; because `state.settings` was present, it wholesale replaced `initialState.settings` and the renderer crashed in `useActiveTheme` → `resolveTheme` → `findCustom(...customs.find)` on undefined. Introduce a shared `mergeWireSnapshot(state)` helper that merges each slice individually against `initialState`, and tighten `setSnapshot`'s parameter type to `WireSnapshotState` (`{[K in keyof AppState]?: Partial<AppState[K]>}`) so the wire boundary stops claiming snapshots are always complete. The helper's `AppState` return type forces a compile error if a future slice is added to `initialState` and the merge function isn't updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
frenchie4111
force-pushed
the
fix/snapshot-merge-per-slice
branch
from
May 25, 2026 17:32
d19ba03 to
6f7643a
Compare
frenchie4111
added a commit
that referenced
this pull request
May 25, 2026
The per-slice wire-merge introduced in #77 (d1aa7b9) enumerates every AppState slice; the announcements slice that landed on this branch needs the same merge line so the AppState object literal still satisfies the type. TypeScript catches this by design — that's the guard the new helper documents. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
frenchie4111
added a commit
that referenced
this pull request
May 25, 2026
* feat(announcements): shared slice + settings fields Adds an announcements slice (items + lastFetched + lastError) wired into the root reducer alongside existing slices. Settings gets two new fields, dismissedAnnouncementIds and announcementsMuted, with events and tests, so the per-banner `×` and the "Hide all" action can persist across reloads via the existing settings persistence path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(announcements): main-process poller + persistence + IPC Adds AnnouncementsPoller that fetches harness.mikelyons.org/announcements.json on start and every 6h with a 10s AbortController timeout. Each entry is validated strictly: id/title/href/publishedAt are required strings, href must parse as an http(s) URL, and publishedAt must be a parseable date — malformed entries are dropped individually with a debug-log line, the rest of the feed is preserved. Network failures dispatch fetchFailed and stay silent in the UI. dismissedAnnouncementIds and announcementsMuted persist through the existing config.json path so dismissals survive a reload. Three IPC handlers (announcements:refresh / :dismiss / :mute) expose the writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(announcements): renderer hook + banner UI useAnnouncements() exposes the slice; the App-level useMemo filters expired + dismissed entries, returns null when muted, otherwise picks the entry with the newest publishedAt. The banner sits below the update banners using the accent semantic color so it doesn't compete with update green/info. Title links to href via shell.openExternal; × dismisses just this id; the ⋯ menu offers "Hide all announcements" which sets announcementsMuted. Window focus piggybacks an announcements:refresh alongside the existing PR stale-refresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(announcements): stretch menu wrapper so dropdown clears drag-region The kebab button's wrapper was sized to the button (~22px), but the banner row is ~42px tall with py-2.5 padding. items-center on the row centered the wrapper vertically, leaving the absolute menu's top edge inside the row — and the row's drag-region captured clicks on the menu's top ~6px (no-drag isn't reliably inherited by absolutely-positioned descendants in every Webkit build). self-stretch + flex items-center stretches the wrapper to the full row height while keeping the button visually centered, so top-full now anchors the menu cleanly below the row. Also tagged the menu container and the menuitem with no-drag as defense in depth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(announcements): wire announcements slice into mergeWireSnapshot The per-slice wire-merge introduced in #77 (d1aa7b9) enumerates every AppState slice; the announcements slice that landed on this branch needs the same merge line so the AppState object literal still satisfies the type. TypeScript catches this by design — that's the guard the new helper documents. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(site): empty announcements feed before banner ships The welcome entry was test data for verifying the banner renders end-to-end during development. Empty the array so the feature ships with no banner showing by default — future announcements get added deliberately, not because a placeholder slipped through. The file stays in place (rather than getting deleted) so the URL returns valid JSON and the poller's success path runs cleanly instead of logging a 404 on every refresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An Electron Harness client built after v2.9.3 crashes the renderer when it connects to a remote
harness-serverrunning v2.9.3 or older:Repro. Commit
99262b2(light/dark/system theme mode + custom themes from disk, 2026-05-22) addedcustomThemes: CustomTheme[]toSettingsState. v2.9.3 was released the day before (2026-05-21), so its snapshot'ssettingsobject has nocustomThemes. The renderer'ssetSnapshotdid a top-level shallow merge ({...initialState, ...state}) — that only protects against entirely-missing slices. Because the v2.9.3 server does sendsettings, the merge wholesale replacedinitialState.settings(including thecustomThemes: []default) with the partial wire object, anduseActiveTheme→resolveTheme→findCustom(...customs.find)blew up onundefined.The fix
mergeWireSnapshot(state)insrc/shared/state/index.tswalks every slice and merges{ ...initialState[slice], ...state[slice] }. Fixes both skew cases — missing slice and missing field inside a slice — uniformly.WireSnapshotState = { [K in keyof AppState]?: Partial<AppState[K]> }is whatsetSnapshotnow accepts. Stops the wire boundary from lying about completeness.StateSnapshot.statestays asAppStatebecause the sender (main process) genuinely produces a full shape; the structural-subtype path lets existingsetSnapshot(snapshot.state)callers keep working without churn.mergeWireSnapshotreturnsAppState, so any future PR that adds a slice toAppState/initialStateand forgets to add a line to the merge function gets a TypeScript build failure (the merge object literal won't satisfyAppState). Same forced-update story as adding toinitialState.Test plan
npm run typechecknpx electron-vite buildnpx vitest run src/shared src/renderer— 634 tests pass, including 4 new ones insrc/shared/state/wire-merge.test.tscovering: (1) old server missingcustomThemes, (2) old server missing entire slice, (3) full snapshot pass-through, (4) explicit empty array from mid-version server.findCustomcrash.🤖 Generated with Claude Code